組合(composition) 是指將不同的小部分結合成一個複雜的整體,整體也僅是一部分,並非代表全部,以音樂作為比喻,音樂是由音符組合而成,但音樂不僅是由音符組合而成。
以軟體設計的角度來看,可以使用物件導向的組合技巧來將簡單、獨立的物件組合成更大、更複雜的整體。在組合過程中,較大的物件與其元件(零件)之間建立了「含有什麼」的關係。
還記得嗎?物件間透過 介面 進行通訊,較大的物件即是一種角色,而它會與任何扮演相關角色的物件進行合作。
Bicycle類別負責回應spares訊息,這則訊息傳回一個備件清單,自行車有多個零件,因此自行車與零件之間的關係就像是組合。
轉換Bicycle類別使用組合技巧:
Bicycle類別轉換為使用組合而不是繼承的方式。Bicycle類別負責回應spares訊息,現在將這個責任委派給一個新的Parts物件。Parts類別,將備件訊息委派給Parts物件。Bicycle與Parts之間建立了組合關係,Bicycle包含一個Parts物件。在這條線的 Parts那端有數字「1」,它表示每一個Bicycle都只有一個Parts物件。
Bicycle現在要負責三件事:知道其size、持有其Parts並回應其spares,這個轉換簡化了Bicycle的職責。
class Bicycle
  attr_reader :sizez :parts
  def initialize(args={}) 
    @size = args[:size] 
    @parts = args[:parts]
  end
  def spares
    parts.spares
  end 
end
Bicycle裡移除的parts行為轉移到一個新的Parts層次結構,Parts擁有兩個子類別:RoadBikeParts和MountainBikeParts。

class Parts
  attr_reader :chain, :tire_size
	def initialize(args={})
		@chain = args[:chain] || default_chain
		@tire_size = args[:tire_size] || default_tire_size
		post_initialize(args)
	end
    def spares
	  {tire_size: tire_size,
		  chain: chain).merge(local_spares)
    end
    def default_tire_size
	  raise NotImplementedError
    end
	# 子類別可以覆蓋
	def post_initialize(args)
	  nil
	end
    def local_spares
      ()
    end
   def default_chain
     '10-speed'
   end 
end
class RoadBikeParts < Parts
  attr_reader :tape_color
  def post_initialize(args) 
	@tape_color = args[:tape_color]
  end
  def local_spares
	{tape_color: tape_color} 
  end
  def default_tire_size
	'23'
  end 
end
class MountainBikePairts < Parts
attr_reader :front_shockz :rear_shock
  def post_initialize(args) 
	@front_shock = args[:front_shock] 
	@rear_shock = args[:rear_shock]
  end
	
  def local_spares
	{rear_shock: rear_shock}
  end
	
  def default_tire_size
	'2.1'
  end 
end
road_bike = 
  Bicycle.new( 
	size: 'L',
	parts: RoadBikeParts.new(tap_color: 'red'))
road_bike.size # -> 'L' 
road_bike.spares
# -> {:tire_size=>"23",
#     :chain=>"10-speed",
#     :tape_color=>"red"}
mountain_bike =
  Bicycle.new (
	 size: 'L',
     parts: MountainBikeParts.new(rear_shock: 'Fox'))
mountain_bike.size # -> 'L' 
mountain_bike.spares
# -> {:tire_size=>"2.1",
#     :chain=>"10-speed",
#     :rear_shock=>"Fox"}
除了類別名稱不同,同時移除了size變數,另外,只要擁有的是RoadBikeParts或 MountainBikeParts,自行車都可以正確地回應其size和spares。
要表示個別零件,需要創建一個Part類別,Part類別用於代表個別零件的特性和行為。由於已經存在一個名為Parts的類別,引入Part類別可能會造成溝通問題,在命名上要區分單複數。
零件淸單會包含一長串的個別零件,現在要開始增加能夠表示個別零件的類別,簡單地說我們的目標是有一個Parts物件,它可能包含多個Part物件。
Bicycle會傳送spares給Parts,接著Parts物件會傳送needs_spare給每一個Part。Part的「1..」表示一個Parts擁有一個或更多的Part物件。
Part類別可以簡化現有的Parts類別,Parts類別現在已變成一個簡單的包裝器,將一組Part物件包裝起來,並且過濾Part物件清單,傳回需要備件的Part物件。
目前現有的Bicycle類別,更新後的Parts類別以及新引入的Part類別如下:
class Bicycle
  attr_reader :size, :parts
  def initialize(args ={}) 
    @size = args[:size] 
    @parts = args[:parts]
  end
  def spares
    parts.spares
  end 
end
class Parts
  attr_reader :parts
  def initialize(parts) 
    @parts = parts
  end
  def spares
    parts.select {|part| part.needs_spare}
  end 
end
 
class Part
  attr_reader :name, :description, :needs_spare 
  def initialize(args)
    @name = args[:name]
    @description = args[:description]
    @needs_spare = args.fetch(:needs_spare, true)
  end
end
組合Parts的方式有下面兩種:
有了Bicycle, Parts, Part這三個類別之後,我們便可以自由建立個別的 Part物件:
chain = Part.new(name: 'chain', description: '10-speed') 
road_tire = Part.new(name: 'tire_size', description: '23') 
tape = Part.new(name: 'tape_color', description: 'red')
mountain_tire = Part.new(name: 'tire_size*, description: '2.1')
rear_shock = Part.new(name: 'rear_shock', description: 'Fox')
front_shock = Part.new (name: 'front_shock', description:  
'Manitou, needs_spare: false)
個別的Part物件可以再被組合成一個Part。
road_bike_parts = Parts.new([chain, road_tire, tape])
建立Bicycle時直接建構Parts物件
road_bike =
 Bicycle.new(
 	size: 'L',
 	parts: Parts.new([chain, road_tire, tape]))
road_bike.size # -> 'L'
road_bike.spares
#-> [#<Part:0x00000101036770 
#.       @name="chain",
#.       @description="10-speed",
#.       @needs_spare=true>, 
#.   #<Part:0x0000010102dc60
#.       @name="tire_size",
#.       等等....
mountain_bike =
  Bieyele.new(
    size: 'L',
    parts: Parts.new([chain, mountain_tire, front_shock,   
                    rear_shock]))
mountain_bike.size # -> 'L' 
moimtain_bike.spares
#-> [#<Part:0x00000101036770 
#.       @name="chain”,
#.       @description="10-speed", 
#.       @needs_spare=true>,
#.    #<Part:0x0000010101b678 
#.       @name=" tire_size", 
#.       等等....
差異:
Part物件,Bicycle原有的spares方法會傳回一個散列表Bicycle時直接建構Parts物件,spares方法則會傳回一個Part物件陣列。照理說Bicycle的parts和spares都應該傳回相同的內容,然而物件所回傳的並不相同。
spares會傳回一個陣列(由Part物件組成),且Array能夠明白size。parts會傳回Parts實例,但它對size並不理解,因此造成以下錯誤。mountain_bike.spares.size # -> 3
mountain_bike.parts.size
# -> NoMethodError:
# undefined method 'size' for #<Parts:...>
因此,我們必須做以下修改:
Forwardable模組,這允許你將某些方法的呼叫委派給@parts實例變數中的物件。這樣做可以讓Parts類別具有size和each方法,這些方法實際上是由 @parts物件提供的include Enumerable,這表示你可以在Parts上使用像select這樣的 Enumerable方法。Parts類別來包裝一個陣列,該陣列包含了自行車的零件(例如chain、mountain_tire等)require 'forwardable'
class Parts
  extend Forwardable
  def_delegators :@parts, :size, :each 
  include Enumerable
  def initialize(parts) 
    @parts = parts
  end
  def spares
    select {|part| part.needs_spare)
  end
end
mountain_bike =
  Bicycle.new (
	size: 'L ',
	parts: Parts.new([chain, mountain_tire, front_shock, rear_shock])) 
mountain_bike.spares.size # -> 3 
mountain_bike.parts.size # -> 4
回顧上方程式碼(4~7行)。Part物件存放在chain、mountain_tire等變數裡面。 在應用程式裡的某個地方,會有物件必須要知道如何建立這些Part物件。而在上方的程式碼(4~7行),這種知識泄漏將特定物件的建立細節暴露給其他部分,不是一個理想的設計方式。
這邊會使用到工廠模式,協助我們建立PartsFactory的模組:
工廠模式
PartsFactory模組
建立新的PartsFactory模組,它負責接收配置信息並製造出Parts物件。
命名build方法並接收三個參數:
config:這是一個陣列,其中包含了零件的描述信息,每個元素都是一個子陣列,包含零件的名稱、描述和是否需要備件。part_class:這是一個可選的參數,用於指定要使用的零件類別,預設值為 Part。parts_class:這是另一個可選的參數,用於指定要使用的部件集合類別,預設值為 Parts。由於PartsFactory瞭解config的內部結構,所以config可以被指定為陣列而非散列表。使用config.collect迭代config陣列的每個元素,並為每個零件創建一個 part_class的實例。在這個過程中,從子陣列中提取零件的名稱、描述和需要備件的信息。
這個PartsFactory用於建立Parts物件,並且能夠根據提供的config陣列創建零件,並將它們添加到Parts物件中。此方法提高了程式碼的可維護性和靈活性,因為它把配置知識集中在一個地方,並且允許在不修改程式碼的情況下調整Parts物件的建立過程。
module PartsFactory
  def self.build(config,
    part_class = Part, 
    parts_class = Parts)
    parts_class.new(
      config.collect {|part_config|
        art_class.new(
          name: part_config[0],
          description: part_config[1],
          needs_spare: part_config.fetch(2, true))})
  end
end
既然有了PartsFactory,那麼你就可以使用上面所定義的設定陣列輕鬆地建立新的 Parts。在PartsFactory與新的設定陣列相結合之後,它會將所有建立有效Parts所需要的知識隔離起來。
road_parts = PartsFactory.build(road_config) 
# -> [#<Part:0x00000101825b70
#.        @name="chain", 
#.        @description="10-speed", 
#.        @needs_spare=true>,
#.    #<Part:0x00000101825b20 
#.        @name= "tire_size", 
#.        等等.....
Mountain_parts = PartsFactory.build(mountain_config)
# -> [#<Part:0x0000010181ea28 
#.        @name="chain",
#.        @description="10-speed", 
#.        @needs_spare=true>, 
#.    #<Part:0x0000010181e9d8
#.        @name="tire_size",
#.        等等.....
OpenStruct的彈性比Struct更大,它的初始化參數是一個hash,可以把屬性當做method來處理,直接指定屬性內容、直接讀取。(關於OpenStruct與Struct的差異與使用,大家可以參考這篇文章)
修改PartsFactory,使用OpenStruct來取代扮演Part角色的物件,如此你便能夠清除掉Part的所有痕跡。
require 'ostruct'
# OpenStruct class不包含在原本Core物件當中,因此需要先require
module PartsFactory
  def self.build(config, parts_class = Parts) 
	parts_class. new (
	  config.collect {|part_config| 
		create_part(part_config)})
  end
  def self.create_part(part_config) 
	OpenStruct .new(
	  name: part_config[0],
	  description: part_config[1],
	  needs_spare: part_config.fetch(2, true))
  end
end
現在,它會傳回一個Parts,Parts含有一個OpenStruct物件陣列,而且每一個物件都扮演了Part角色。
mountain_parts = PartsFactory.build(mountain_config) 
# -> <Parts:0x000001009ad8b8 @parts=
#.      [#<OpenStruct name="chain", 
#.                    description="10-speed",
#.                    needs_spare=true>,
#        #<OpenStruct name="tire_size",
#.                    description="2.1",
#.                    等等.....
有沒有覺得組合的非常有趣?從Bicycle需求出發而有了Parts物件,透過組合的方式來運作,今天,先組完Parts與Part;明天,我們繼續組Bicycle
參考資料: